跳到主要内容

Java TCP编程

Socket 概述

参考资料 菜鸡小王子--TCP/IP、Http、Socket的关系理解

socket 是基于应用服务与 TCP/IP 通信之间的一个抽象,他将 TCP/IP 协议里面复杂的通信逻辑进行分装,对用户来说,只要通过一组简单的 API 就可以实现网络的连接。

Socket 是对 TCP/IP 协议的封装,Socket 本身并不是协议,而是一个调用接口(API),通过Socket,才能使用TCP/IP协议

Socket 的出现只是使得程序员更方便地使用 TCP/IP 协议栈而已,就是一些最基本的函数接口,比如 createlistenconnectacceptsendreadwrite 等等。

注意:是 TCP/IP 协议,所以不只是处理 TCP

数据报类型套接字 SOCK_DGRAM(面向 UDP 接口) 流式套接字 SOCK_STREAM(面向 TCP 接口) 原始套接字 SOCK_RAW(面向网络层协议接口 IP、ICMP 等)

使用 TCP 通信

TCP 通信能实现两台计算机之间的数据交互,通信的两端,要严格的区分客户端(Client)与服务端(Server)

┌───────────┐                                   ┌───────────┐
│Application│ │Application│
├───────────┤ ├───────────┤
│ Socket │ │ Socket │
├───────────┤ ├───────────┤
│ TCP │ │ TCP │
├───────────┤ ┌──────┐ ┌──────┐ ├───────────┤
│ IP │<────>│Router│<─────>│Router│<────>│ IP │
└───────────┘ └──────┘ └──────┘ └───────────┘

连接过程分为三个步骤:服务器监听,客户端请求,连接确认。

1、服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态,等待客户端的连接请求。

2、客户端请求:指客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。

3、连接确认:当服务器端套接字监听到或者说接收到客户端套接字的连接请求时,就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户端,一旦客户端确认了此描述,双方就正式建立连接。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

两端通信时步骤

1、 服务端程序,需要事先启动(创建服务器 ServerSocket 对象和系统要指定的端口号),等待客户端的连接(使用 ServerSocket 对象中的方法 accept 方法获取到请求的客户端对象 Socket 对象,这个 accept 方法会阻塞当前线程,直到下一个 Socket 到了才会执行后面的语句)

2、 服务端不会主动的请求客户端,必须 使用客户端请求服务端

3、 客户端和服务端就会建立一个逻辑连接,而这个连接中包含一个流对象(使用 Socket 对象中的方法 getInputStream() 获取网络字节输入流 InputStream 对象)

4、 使用 Scanner 对象中的方法 readLine 读取客户端发送的数据,使用 Socket 对象中的方法 getOutputStream() 方法获取网络字节输出流 OutputStream 对象,使用 PrintWriter 中的 print,给客户端回写数据(注意:使用 Scanner 的 readLine 获取消息时,这边的 PrintWriter 就必须用 print 而不能用 write,否则会导致消息获取不了)

5、 因为通信的数据不仅仅是字符,所有流对象是 字节流对象

6、释放资源(先释放获取到的 Socket 再释放 ServerSocket)

在 Java中,提供了两个类用于实现 TCP 通信程序

1、 客户端:java.net.Socket 类,创建 Socket 对象,向服务端发出连接请求,服务端响应请求,两者建立连接开始通信

2、 服务端:java.net.ServerSocket 类,创建 ServerSocket 对象,相当于开启一个服务,并等待客户端的连接

结束传输标记

参考资料 AncientCoin--Java通过Socket传输文件以及判断文件传输完成的方法 参考资料 dabing69221--shutdownInput & shutdownOutput

因为,这个网络编程读取的数据不会像文件读取那样结尾就返回一个 -1 而是 自行判断是否已经传输完毕,完毕后跳出循环,否则程序就会一直阻塞在 read() 这里,因为 socket 还没有断开,会一直等待用户写数据。

所以需要从客户端发送一个标识符用来表示数据已经发完了(如果是传输的文件的话可以通过文件大小来判断,或者hash码)

或者可以使用 shutdownInput 方法来告诉服务器上传完毕,调用了这个方法之后会返回一个 -1 给服务端

//通知服务器上传完毕
socket.shutdownOutput();

调用 Socket.shutdownInput 后, 禁用此套接字的输入流,发送到套接字的输入流端的任何数据都将被确认然后被静默丢弃。任何想从该套接字的输入流读取数据的操作都将返回 -1

套接字的输入流中读取数据时,如果到达输入流末尾时会得到一个 -1 这时停止读取就行了

注:Socket 编程时,不要在关闭写(writer)之前关闭读(reader)

PrintWriter 与 Scanner的使用

参考资料 Socket之 PrintWriter 与 Scanner的使用

PrintWriter 与 Scanner 这两个工具类,专门用来处理字符串信息的

PrintWriter : 高级输出流 Scanner : 高级输入流

public class TestPS {

public static void main(String[] args) throws Exception {

Socket socket = new Socket();

Scanner scan1 = new Scanner(System.in);//用作键盘录入
Scanner scan2 = new Scanner(new File("aa.txt"));//用作文件读取
Scanner scan3 = new Scanner(socket.getInputStream());//网络通信里面去读取对方发给我的信息

scan1.nextLine();//读取键盘录入的信息
scan2.nextLine();//读取文件里面的信息
scan3.nextLine();//读取socket流里面的信息(服务端或者客户端发送的)


PrintWriter pw1 = new PrintWriter(System.out);
PrintWriter pw2 = new PrintWriter("aa.txt");
PrintWriter pw3 = new PrintWriter(socket.getOutputStream());

//使用输出之后一定要flush
pw1.println("控制台打印");pw1.flush();
pw2.println("写到文件里面");pw2.flush();
pw3.println("发送给服务器或者客户端");pw3.flush();

}
}

TCP实现聊天室

服务端:负责消息转发和广播;

客户端:发送消息,接收消息;

聊天室服务端

服务端必须明确一件事,必须知道是哪个客户端请求的服务器,所以可以使用 accept 方法去获取到请求的客户端对象 Socket 成员方法:

//侦听要连接到此套接字并接受它。  
Socket accept()

然后将其添加到监听队列里面去(监听消息),当有消息发送时再广播消息

public class Server {
/**
* 客户端队列
*/
private static final List<Socket> clients = new ArrayList<>();

private void start() throws IOException {
try (ServerSocket serverSocket = new ServerSocket(8888)) {
System.out.println("服务开启,等待客户端连接中...");
// 循环监听
while (true) {
// 等待客户端进行连接(这个 accept 在下一个连接到来前是阻塞的)
Socket client = serverSocket.accept();
System.out.println("客户端[" + client.getRemoteSocketAddress() + "]连接成功,当前在线用户" + clients.size() + "个");
// 将客户端添加到集合
clients.add(client);
// 每一个客户端开启一个线程处理消息
new MessageListener(client).start();
}
} catch (IOException e) {
// log
}
}

public static void main(String[] args) throws IOException {
new Server().start();
}

/**
* 消息处理线程,主要做消息监听和广播
*/
static class MessageListener extends Thread {
/**
* 将连接上的客户端传入进来
*/
private Socket client;
// 将这几个变量抽出来公用,避免频繁new对象
private OutputStream os;
private PrintWriter pw;
private InputStream is;
private InputStreamReader isr;
private BufferedReader br;


public MessageListener(Socket client) {
this.client = client;
}

@Override
public void run() {
try {
// 每个用户连接上了,就发送一条系统消息(类似于广播)
sendMessage(0, "[系统消息]:欢迎" + client.getRemoteSocketAddress() + "来到聊天室,当前共有" + clients.size() + "人在聊天");
// 循环监听消息
while (true) {
sendMessage(1, "[" + client.getRemoteSocketAddress() + "]:" + receiveMsg());
}
} catch (IOException e) {
// log
}
}

/**
* 发送消息
*
* @param type 消息类型(0、系统消息;1、用户消息)
* @param msg 消息内容
*/
private void sendMessage(int type, String msg) throws IOException {
if (type != 0) {
System.out.println("处理消息:" + msg);
}
for (Socket socket : clients) {
if (type != 0 && socket == client) { // 不需要再发送消息给自己
continue;
}
// 先获取网络输出流
os = socket.getOutputStream();
pw = new PrintWriter(os);
// 这里需要特别注意,对方用 readLine 获取消息,就必须用print而不能用write,否则会导致消息获取不了
pw.println(msg);
pw.flush();
}
}

/**
* 接收消息
*
* @return 消息内容
*/
private String receiveMsg() throws IOException {
is = client.getInputStream();
isr = new InputStreamReader(is);
br = new BufferedReader(isr);
return br.readLine();
}
}
}

聊天室客户端

客户端有两个线程,一个发送消息的线程,一个接受消息的线程

开启多次 main 需要设置一下 IDEA,这个 Allow parallel run 勾上

image.png

public class Client {

private Socket server = null;

private OutputStream os;
private PrintWriter pw;
private InputStream is;
private InputStreamReader isr;
private BufferedReader br;

private void start() {
try {
// 连接服务器
server = new Socket("127.0.0.1", 8888);
System.out.println("连接服务器成功,身份证:" + server.getLocalSocketAddress());
// 启动接受消息的线程
new ReceiveMessageListener().start();
// 启动发送消息的线程
new SendMessageListener().start();
} catch (SocketException e) {
System.out.println("服务器" + server.getRemoteSocketAddress() + "嗝屁了");
} catch (IOException e) {
// log
}
}


/**
* 发送消息
*
* @param msg 消息内容
* @throws IOException
*/
private void sendMsg(String msg) throws IOException {
os = server.getOutputStream();
pw = new PrintWriter(os);
pw.println(msg);// 这里需要特别注意,对方用readLine获取消息,就必须用print而不能用write,否则会导致消息获取不了
pw.flush();
}

/**
* 接受消息
*
* @return 消息内容
* @throws IOException
*/
private String receiveMsg() throws IOException {
is = server.getInputStream();
isr = new InputStreamReader(is);
br = new BufferedReader(isr);
return br.readLine();
}


public static void main(String[] args) {
new Client().start();
}

/**
* 发送消息的线程
*/
class SendMessageListener extends Thread {
@Override
public void run() {
try {
// 监听idea的console输入
Scanner scanner = new Scanner(System.in);
// 循环处理,只要有输入内容就立即发送
while (true) {
sendMsg(scanner.next());
}
} catch (SocketException e) {
System.out.println("服务器" + server.getRemoteSocketAddress() + "嗝屁了");
} catch (IOException e) {
// log
}
}
}

/**
* 接受消息的线程
*/
class ReceiveMessageListener extends Thread {
@Override
public void run() {
try {
// 循环监听,除非掉线,或者服务器宕机了
while (true) {
System.out.println(receiveMsg());
}
} catch (SocketException e) {
System.out.println("服务器" + server.getRemoteSocketAddress() + "嗝屁了");
} catch (IOException e) {
// log
}
}
}
}

文件上传

实现步骤:

1、客户端使用 本地的字节输入流,读取要上传的文件

2、客户端使用 网络字节输出流,把读取到的文件上传到服务器

3、服务器 使用 网络字节输入流,读取客户端上传的文件

4、服务器 使用 本地字节输出流,把读取到的文件保存到本地

5、服务器 使用 网络字节输出流,给客户端回写一个 上传成功

6、客户端使用 网络字节输入流,读取服务器回写的数据

7、释放资源

注意:

  • 客户端和服务端和本地硬盘进行读写,需要使用自己创建的字节流对象(本地流)
  • 客户端和服务端之间进行读写,必须使用 Socket 中提供的字节流对象(网络流)

文件上传的原理,就是文件的复制所以需要明确:数据源、数据目的地

如果需要上传多个文件,服务端这里一般是不能把文件写死的,这时就需要自定义一套命名规则(一般直接用 MD5)

规则:毫秒值+随机数

String fileName = System.currentTimeMillis() 
+ new Random().nextInt(9999)+".jpg";

客户端

public class FileUpClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1",8888);
String name = "temp.jpg";
String path = "C:\\Users\\alsritter\\Desktop";
File file = new File(path,name);

//使用Buffer加快上传&读取
BufferedOutputStream os = new BufferedOutputStream(socket.getOutputStream());
//先取得本地数据
BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file));
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
os.write(buffer,0,len);
}
//通知服务器上传完毕
socket.shutdownOutput();


//再读取服务端发回来的数据
InputStream is = socket.getInputStream();
while ((len = is.read(buffer))!=-1) {
System.out.println(new String(buffer, 0, len));
}

inputStream.close();
socket.close();
}
}

服务端

把服务器改成无限上传的模式,加个 while 一直监听就行了

使用多线程来满足多用户的请求

检查多线程的方法

// 查看开启了几条线程
//Java 虚拟机线程系统的管理接口
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
//不需要获取同步monitor 和 synchronize信息,仅仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false,false);
//遍历线程信息,仅仅打印线程ID和线程名称信息
for(ThreadInfo threadInfo:threadInfos){
System.out.println("["+threadInfo.getThreadId()+"]"+threadInfo.getThreadName());
}

下面是实现代码

public class FileUpServer {
public static void main(String[] args) throws IOException {
ServerSocket server = new ServerSocket(8888);
String path = "C:\\Users\\alsritter\\Desktop";

while (true) {
Socket socket = server.accept();

new Thread(() -> {
//先生成一个随机的名字
String name = System.currentTimeMillis() + new Random().nextInt(9999) + ".jpg";
try(
//使用Buffer
BufferedInputStream is = new BufferedInputStream(socket.getInputStream());
BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream(new File(path, name)))
) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
os.write(buffer, 0, len);
}

OutputStream outputStream = socket.getOutputStream();
outputStream.write("服务器已经收到了".getBytes());


// 查看开启了几条线程
//Java 虚拟机线程系统的管理接口
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
//不需要获取同步monitor 和 synchronize信息,仅仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false,false);
//遍历线程信息,仅仅打印线程ID和线程名称信息
for(ThreadInfo threadInfo:threadInfos){
System.out.println("["+threadInfo.getThreadId()+"]"+threadInfo.getThreadName());
}

System.out.println(Thread.currentThread().getName()+"线程把文件下载下来了");
socket.close();
}catch (IOException e){
e.printStackTrace();
}
}).start();


}
}
}

实现 HTTP 协议

直接使用 TCP 通信虽然简单,但是 必须收发双方都知道怎么接受数据以及传输数据(例如发送一个 :end 来判断结束输入之类的操作),浏览器的每个页面实际都是一个“客户端” 要是每个都是自定义规则,那基本互联网也发展不起来

所以就出了 HTTP 这个规范,告诉客户端应该怎么请求数据,服务端应该怎么响应数据

而 Java 的 Tomcat 容器实际上就是做这事的,下面的例子毕竟只是一些原理,实际在项目里去实现一大堆 HTTP 协议的内容太麻烦了,所以需要一个帮忙处理 HTTP 协议的工具,因此就出现了 Tomcat,Tomcat 使用起来也方便,只需要部署一个 war 包,Tomcat 就会自动把 HTTP 协议相关内容封装成 RequestResponse 对象,供程序员调用

服务端取得请求

实际就把客户端换成浏览器

tJrwv9.png

服务器取得客户端的请求

// 服务器代码
public class TCPServer {
public static void main(String[] args) throws IOException {
// 步骤基本和之前一样
ServerSocket serverSocket = new ServerSocket(8888);
Socket socket = serverSocket.accept();
InputStream is = socket.getInputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
System.out.println(new String(buffer, 0, len));
}
}
}

可以从服务器获取到客户端(浏览器)传来的信息

GET /web/index.html HTTP/1.1
Host: 127.0.0.1:8888
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8

取得地址的原理

而服务端的目标是给客户端回写一个信息(HTML)

读取 index.html 文件,就需要知道这个资源的地址,所以需要解析请求头

而这个地址就是前面客户端返送过来的 请求码第一行

GET /web/index.html HTTP/1.1

可以使用 BufferedReader 中的方法 readLine 读取一行

// 把网络字节输入流转为字符缓冲输入流
InputStream is = socket.getInputStream();
new BufferedReader(new InputStreamReader(is));

取得这行之后就需要切割字符串,只需要把地址取出来就行了

GET /web/index.html HTTP/1.1

可以使用String类的方法 split(" ") 切割字符串,获取中间的部分,然后取数组第二位 arr[1] 获取到由空格切割开的第二个元素 /web/index.html

由于不需要这个地址前面那个/所以再次切割 使用String类的substring(1),获取到html文件的路径 web/index.html

然后服务端就可以创建一个本地的字节输入流,根据获取到文件路径,读取HTML文件

// 写入HTTP协议的响应头(固定写法)
out.write("HTTP/1.1 200 OK\r\n".getBytes());
out.write("Content-Type:text/html\r\n".getBytes());
//必须要写入空行,否则浏览器不解析
out.write("\r\n".getBytes());

最后服务器端使用网络字节输出流把读取到的文件,写到客户端(浏览器)显示

读取资源地址实例

tJo7C9.png

/**
* 创建BS版本的TCP服务器
*
* @author alsritter
* @version 1.0
**/
public class TCPServer {
public static void main(String[] args) throws IOException {
// 步骤基本和之前一样
ServerSocket serverSocket = new ServerSocket(8888);
Socket socket = serverSocket.accept();
InputStream is = socket.getInputStream();
/*
因为只需要读取第一行,就不需要使用这种形式把数据全部读取过来了
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
System.out.println(new String(buffer, 0, len));
}
*/
// 取得地址
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String str = reader.readLine();
String[] arr = str.split(" ");
// 默认的根目录是从src目录开始
String path = "src\\" + arr[1].substring(1);

//先把html协议响应头给返回去
OutputStream out = socket.getOutputStream();
// 写入HTTP协议的响应头(固定写法)
out.write("HTTP/1.1 200 OK\r\n".getBytes());
out.write("Content-Type:text/html\r\n".getBytes());
//必须要写入空行,否则浏览器不解析
out.write("\r\n".getBytes());

//BufferedReader readerHTML = new BufferedReader(new FileReader(path));
BufferedInputStream readerHTML = new BufferedInputStream(new FileInputStream(path));
byte[] buffer = new byte[1024];
int len;
while ((len = readerHTML.read(buffer)) != -1) {
out.write(buffer,0,len);
}
}
}

包括读取其他资源

浏览器解析服务器回写的 html 页面,页面中如果有其他链接(例如引用其他资源),那么浏览器就会单独的开启一个新线程去读取服务器的资源,所以需要让服务器一直处于监听状态,客户端请求一次,服务器就回写一次

例子的资源目录

tJXeOA.png

注意,这里有个坑,就是浏览器默认会发送一个请求 favicon.ico 文件(就是你顶部链接栏的那个小图标),如果没有,服务端需要进行相应处理,否则会报资源找不到错误且,每种资源的Content-Type都不一样,需要进行相应的处理

/**
* 创建BS版本的TCP服务器
* 访问 http://127.0.0.1:8888/web/index.html
*
* @author alsritter
* @version 1.0
**/
public class TCPServerThread {
public static void main(String[] args) throws IOException {
// 步骤基本和之前一样
ServerSocket serverSocket = new ServerSocket(8888);
while (true) {
Socket socket = serverSocket.accept();
new Thread(()->{
try (
InputStream is = socket.getInputStream();
InputStreamReader inputStreamReader = new InputStreamReader(is);
BufferedReader reader = new BufferedReader(inputStreamReader);){
// 取得地址
String str = reader.readLine();
String[] arr = str.split(" ");
// 默认的根目录是从src目录开始
String path = "src\\" + arr[1].substring(1);
System.out.println(path);

//先把html协议响应头给返回去
OutputStream out = socket.getOutputStream();
// 写入HTTP协议的响应头(固定写法)
out.write("HTTP/1.1 200 OK\r\n".getBytes());
// 因为有多种资源类型,需要分别响应不同的头
if (path.endsWith(".css")) {
out.write("Content-Type:text/css\r\n".getBytes());
}else if (path.endsWith(".js")){
out.write("Content-Type:text/javascript\r\n".getBytes());
}else if (path.endsWith(".png")){
out.write("Content-Type:image/png\r\n".getBytes());
}else if (path.endsWith(".jpg")){
out.write("Content-Type:image/jpeg\r\n".getBytes());
}else {
out.write("Content-Type:text/html\r\n".getBytes());
}



//必须要写入空行,否则浏览器不解析
out.write("\r\n".getBytes());

FileInputStream fileInputStream = new FileInputStream(path);
BufferedInputStream readerHTML = new BufferedInputStream(fileInputStream);
byte[] buffer = new byte[1024];
int len;
while ((len = readerHTML.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
readerHTML.close();
fileInputStream.close();
}catch (IOException e){
e.printStackTrace();
}

}).start();
}

}
}